Anton Lydike — Blog
Website GitHub

Adding a Theme Toggle to my Website

Written: 2025-08-02
Tags: #how-to #web

I like providing light/dark themes to users to suit their preferences, and I wanted to try out adding a fancy "toggle-theme" button to my website. Here's the write-up of my the solution I arrived at (which you can see on this website!).

Goals:

Basics: CSS-Only, no toggling

I generally like to set up my css styles through CSS variables, so I have something like this:

:root {
    --background: #fff;
    --foreground: #111;
    --foreground-light: #333;
    --background-light: #eee;
    --accent-1: #ea9e41;
    /* ... */
}

To respect the users theme choice, a css media query can be used:

@media (prefers-color-scheme: dark) {
    :root {
        --background: #333;
        --foreground: #fff;
        --foreground-light: #ddd;
        --background-light: #555;
        --accent-1: #ea9e41;
        /* ... */
    }
}

This is a really nice baseline, and in fact what I used for my personal website for the longest time. However, we can allow the user to toggle his preferences on the website:

Controllable: CSS Class Override

To allow the user to choose, I settled on adding overriding classes to the html element. To allow this, we sadly need to copy our generated styles. Here a CSS pre-processor would come in handy, but sadly I am not using one myself.

We add overrides for the two theme classes, and our CSS now looks like this:

:root {
    --background: #fff;
    --foreground: #111;
    --foreground-light: #333;
    --background-light: #eee;
    --accent-1: #ea9e41;
    /* ... */
}
:root.theme-dark {
    --background: #333;
    --foreground: #fff;
    --foreground-light: #ddd;
    --background-light: #555;
    --accent-1: #ea9e41;
    /* ... */
}
@media (prefers-color-scheme: dark) {
    :root:not(.theme-light, .theme-dark) {
        --background: #333;
        --foreground: #fff;
        --foreground-light: #ddd;
        --background-light: #555;
        --accent-1: #ea9e41;
        /* ... */
    }
}

We have now duplicated the variables for the dark theme, which is not ideal. But, adding a theme-dark or theme-light class to our html element does indeed cause the page to render in that theme.

Also note that we are using sub-selectors on the :root pseudo-class. This felt icky to me at first, but a glance at the docs assure me that:

The :root CSS pseudo-class matches the root element of a tree representing the document. In HTML, :root represents the <html> element and is identical to the selector html, except that its specificity is higher.

Now we need to add JS controls:

Automation: Adding Switching Mechanisms

In order to prevent the page from flashing light before going dark, we want to avoid doing theme selection after any of the body DOM is constructed. Luckily, due to our choice of adding the theme class to the html element, we can select the theme before any part of the body is even parsed by the browser. For this, we add the following script snippet inline into the <head>:

//get js handle on user-preffered theme
const isDark = window.matchMedia("(prefers-color-scheme: dark)");
// quick way of getting the users preferred theme:
const userPref = () => localStorage.getItem('theme') || (isDark.matches ? 'dark' : 'light')
// grab html element for later use
const html = document.querySelector('html');
// set current selected theme as css class
html.classList.add(`theme-${userPref()}`); 
// register a handler for user-js
window.setTheme = (newTheme) => {
    // remove previous theme class
    html.classList.remove(`theme-${userPref()}`)
    html.classList.add(`theme-${newTheme}`)
    // save theme for next time:
    localStorage.setItem('theme', newTheme)
}

This script does a few things: 1. Construct a MediaQueryList object through window.matchMedia 2. Provides a helper to detect the current theme userPref() that checks the browsers local storage first, and if nothing was found, falls back to the media queries preference. 3. It then adds the currently selected theme class to the html element 4. Provides another helper function setTheme(name), which updates the class on the html element and saves the selected theme to local storage

We can extend this mechanism with two additional, optional pieces of code:

// listen to writes to localStorage from other tabs
window.addEventListener('storage', e => {
    if (e.key != 'theme' || e.storageArea !== localStorage) return;
    const html = document.querySelector('html')
    if (e.oldValue) html.classList.remove(`theme-${e.oldValue}`)
    if (e.newValue) html.classList.add(`theme-${e.newValue}`)
})

This script registers a listener to storage events to update the current pages style when the theme preference is changed in another tab. Notably, no storage events are created for the window where the change occurred, so no double-updates are caused by this.

A less useful addition is to also listen to the users preferences change:

// when user pref changes, update themes
isDark.addEventListener('change', e => {
    setTheme(isDark.matches ? 'dark' : 'light')
})

It's neat that we can do this, but I'm unsure in which case this could be useful...

The Button:

I guess just having a setTheme function dangling around in the global scope is sort of neat, but doesn't actually help the user much. So let's add a simple button to toggle themes. I saw one online that I liked (but I forgot where), so I built a similar one myself.

It's just a circular button with an icon that floats in the lower-right corner of the screen (as can be seen on this page). The HTML/CSS is fairly simple:

<div class="theme-toggle" onclick="setTheme(userPref() == 'dark' ? 'light' : 'dark')">
    <svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 -960 960 960" width="1em" fill="currentColor">
        <path d="M396-396q-32-32-58.5-67T289-537q-5 14-6.5 28.5T281-480q0 83 58 141t141 58q14 0 28.5-2t28.5-6q-39-22-74-48.5T396-396Zm57-56q51 51 114 87.5T702-308q-40 51-98 79.5T481-200q-117 0-198.5-81.5T201-480q0-65 28.5-123t79.5-98q20 72 56.5 135T453-452Zm290 72q-20-5-39.5-11T665-405q8-18 11.5-36.5T680-480q0-83-58.5-141.5T480-680q-20 0-38.5 3.5T405-665q-8-19-13.5-38T381-742q24-9 49-13.5t51-4.5q117 0 198.5 81.5T761-480q0 26-4.5 51T743-380ZM440-840v-120h80v120h-80Zm0 840v-120h80V0h-80Zm323-706-57-57 85-84 57 56-85 85ZM169-113l-57-56 85-85 57 57-85 84Zm671-327v-80h120v80H840ZM0-440v-80h120v80H0Zm791 328-85-85 57-57 84 85-56 57ZM197-706l-84-85 56-57 85 85-57 57Zm199 310Z"/>
    </svg>
</div>

The SVG is something I borrowed from Googles Material Icons (this one is called "routine" for some reason). I had to modify the svg to accept the parent elements color style for its path, and the hard-coded height and width from 24px to 1em. The icon is held in place in the lower-right through this CSS:

.theme-toggle {
    box-shadow: 0 4px 5px 0 rgba(0,0,0,0.14),0 1px 10px 0 rgba(0,0,0,0.12),0 2px 4px -1px rgba(0,0,0,0.3);
    border-radius: 50%;
    font-size: 1.4em;
    width: 2em;
    height: 2em;
    position: fixed;
    right: 16px;
    bottom: 16px;
    /* this will always make the button appear in the inverted theme: */
    background: var(--foreground);
    color: var(--background);
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
/* change icon color on hover */
.theme-toggle:hover {
    color: var(--url-color);
}
/* hide the toggle when theming isn't supported: */
html:not(.theme-light, .theme-dark) .theme-toggle {
    display: none;
}

With this, the work is complete. Check out the theme-toggle button at the bottom to see it working in action!